Julia数据科学系列-DataFrames包

DataFrames.jl

官方Document

快速开始

构建

DataFrame类型

julia

# 手动构造: 单个元素的列会广播
DataFrame(A=1:3, B=5:7, fied=1)
# 用 => 进行复杂列名DF的构造:
DataFrame("customer age" => [15, 20, 25],
          "first name" => ["A", "B", "C"])

# 也可从"列名"=>"列值"字典/具名元组中构造DF

# 当列名时, Symbols 快过 Strings

# 从矩阵构造DF:
DataFrame(matrix, names) # names 可以设成`:auto`, 则自动分配列名x1, x2 ..., 或者设成与数据列数相同的数组。

# 按列构造:
df = DataFrame()
df.A = 1:8
df.B = ["M", "F", "F", "M", "F", "M", "M", "F"]

# 按行构造:
df = DataFrame(A=Int[], B=String[])
push!(df, (1, "M"))
push!(df, (2, "N"))

# 从其他`Table.jl`的具象格式中转成DF:
df = sqltable |> DataFrame
df = [(a=1, b=2), (a=3, b=4)] |> DataFrame

# 从CSV文件中读取DF:
using CSV
data = CSV.read(path, DataFrame)

# copy
data2 = copy(data1)

julia

基本操作

julia

# 列
german.Sex
german."Sex"
german[!, :Sex]
german[!, "Sex"]
names(german)
names(german, AbstractString) # 获取指定类型列的名字
propertynames(german) # 获取Symbols格式的名字
eachcol(german) # 按列迭代
empty, empty!   # 删除行, 但保留列数(维度)

# DataFrame基本信息
size(df)   # (行, 列)
size(df,1) # 第一维的大小(行数)
size(df,2) # 第二维的大小(列数)
nrow(df), ncol(df)
describe(df) # 基本统计信息mean, min, max, median, nmissing, elementType
describe(df, cols=1:3) # 只统计前三列
show(df, allcols=true)
show(df, allrows=true)

mapcols(func, df) # 按列应用func, 返回新df
mapcols(id -> id .^ 2, german)

first(df, n), last(df, n) # n默认是1

julia

取子集

julia

df[1:3, :]
df[:, [:A, :B]]
df[!, [:A]] # 返回一个df
df[!, :A] # 返回一个Vector

# 正则; Not; Between; All; Cols
df[!, r"x"] # 取出列名包含x的列
df[!, Not(:x1)] # 取出除了`x1`之外的列
df[!, Between(:x1, :x4)]
df[:, All()]
df[:, Cols(x -> startswith(x, "x"))]
# Cols 还可用于给列重排序:
df[:, Cols(r"x", :)] # 把所有包含x的列排在前面, 其他列排在后边
df[:, Cols(Not(r"x"), :)] # 把所有包含x的列排在后面, 其他列排在前边

# 按照数值过滤
df[df.A .> 500, :] #取出所有A列数值大于 500 的行
df[(df.A .> 500) .& (300 .< df.C .< 400), :]
df[in.(df.A, Ref([1, 5, 601])), :]

julia

subsetsubset!: 操作行

subset(df::AbstractDataFrame, args...; skipmissing::Bool=false, view::Bool=false)
subset(gdf::GroupedDataFrame, args...; skipmissing::Bool=false, view::Bool=false,
       ungroup::Bool=true)
julia

subset(df, :A => a -> a .< 10, :C => c -> isodd.(c))

# 用coalesce函数跳过包含missing的行
# coalesce(x...) 返回第一个非missing的值
subset(df, :x => x -> coalesce.(iseven.(x), false)) # 如果是missing, 过滤条件就返回false
# 等价于
subset(df, :x => x -> iseven.(x), skipmissing=true)

julia

select/select!transform/transform!: 操作列

  • 复杂的按列过滤函数

julia

select(df, Not(:x1))
select(df, r"x")
select(df, :x1 => :a1, :x2 => :a2) # 重命名列
select(df, :x1, :x2 => (x -> x .- minimum(x)) => :x2) # 更改x2的数值
select(df, :x2, :x2 => ByRow(sqrt)) # 生成新的名为:x2_sqrt的列, 存放x2列的开平方值
select(df, AsTable(:) => ByRow(extrema) =>  [:lo, :hi]) # 逐行计算所有列的极值, 存到:lo :hi两列中

julia
  • 永远返回DataFrame

  • 默认会从原始df中拷贝一份选择的列, 可以用copycols=false参数关闭copy

df2 = select(df, :x1) df2.x1 === df.x1 # false, 默认是copy了一份新的 df2 = select(df, :x1, copycols=false) df2.x1 === df.x1 # true

transformtransform!

select的用法基本一样, 唯一区别是transform/transform!会同时返回原始df中的所有列

julia

julia> df = DataFrame(x1=[1, 2], x2=[3, 4], y=[5, 6])
2×3 DataFrame
 Row │ x1     x2     y
     │ Int64  Int64  Int64
─────┼─────────────────────
   1 │     1      3      5
   2 │     2      4      6

julia> transform(df, All() => +)
2×4 DataFrame
 Row │ x1     x2     y      x1_x2_y_+
     │ Int64  Int64  Int64  Int64
─────┼────────────────────────────────
   1 │     1      3      5          9
   2 │     2      4      6         12

julia

一个复杂的例子, 按行计算sum, num of elements, mean, 同时忽略missing.

julia

julia> using Statistics

julia> df = DataFrame(x=[1, 2, missing], y=[1, missing, missing])
3×2 DataFrame
 Row │ x        y
     │ Int64    Int64 
─────┼──────────────────
   1 │       1        1
   2 │       2  missing
   3 │ missing  missing

julia> transform(df, AsTable(:) .=>
                    ByRow.([sum∘skipmissing,
                            x -> count(!ismissing, x),
                            mean∘skipmissing]) .=>
                    [:sum, :n, :mean])
3×5 DataFrame
 Row │ x        y        sum    n      mean
     │ Int64    Int64    Int64  Int64  Float64
─────┼─────────────────────────────────────────
   1 │       1        1      2      2      1.0
   2 │       2  missing      2      1      2.0
   3 │ missing  missing      0      0      NaN

julia
Todo DataFramesMeta.jl 模块中有更多好用的数据处理工具, 需要一起记录。

数据统计: describecombine

  • describe: 返回一个df, 记录表格每列基本统计信息mean, min, median, max, nmissing, eltype

julia

describe(df)
describe(df[!, [:A]]) # 只对特定列统计

julia
  • combine: 应用统计函数

julia

combine(df, names(df) .=> sum)
combine(df, names(df) .=> sum, names(df) .=> prod)

julia

数据替换

  • replace!: 对单列进行替换

julia

julia> df = DataFrame(a=["a", "None", "b", "None"], b=1:4,
                      c=["None", "j", "k", "h"], d=["x", "y", "None", "z"])
4×4 DataFrame
 Row │ a       b      c       d
     │ String  Int64  String  String
─────┼───────────────────────────────
   1 │ a           1  None    x
   2 │ None        2  j       y
   3 │ b           3  k       None
   4 │ None        4  h       z

julia> replace!(df.a, "None" => "c")
4-element Vector{String}:
 "a"
 "c"
 "b"
 "c"

julia> df
4×4 DataFrame
 Row │ a       b      c       d
     │ String  Int64  String  String
─────┼───────────────────────────────
   1 │ a           1  None    x
   2 │ c           2  j       y
   3 │ b           3  k       None
   4 │ c           4  h       z

julia

以上操作等价于df.a = replace(df.a, "None" => "c"), 但是是in-place的操作, 不需要重新分配内存。

如果需要对多列进行in-place的替换操作, 则可以用广播来实现:

julia

# replacement on a subset of columns [:c, :d]
julia> df[:, [:c, :d]] .= ifelse.(df[!, [:c, :d]] .== "None", "c", df[!, [:c, :d]]);

julia> df
4×4 DataFrame
 Row │ a       b      c       d
     │ String  Int64  String  String
─────┼───────────────────────────────
   1 │ a           1  c       x
   2 │ c           2  j       y
   3 │ b           3  k       c
   4 │ c           4  h       z

julia> df .= ifelse.(df .== "c", "None", df) # replacement on entire data frame
4×4 DataFrame
 Row │ a       b      c       d
     │ String  Int64  String  String
─────┼───────────────────────────────
   1 │ a           1  None    x
   2 │ None        2  j       y
   3 │ b           3  k       None
   4 │ None        4  h       z

julia

最后一句如果把.=换成=, 则不会执行in-place操作, 会额外分配内存:

julia

julia> @time dfx = ifelse.(df[!, [:c, :d]] .== "NA", "c", df[!, [:c, :d]]);
  0.000104 seconds (92 allocations: 6.781 KiB)

julia> @time dfx .= ifelse.(df[!, [:c, :d]] .== "NA", "c", df[!, [:c, :d]]);
  0.000073 seconds (81 allocations: 5.484 KiB)

julia

Warn 如果原本的列格式并不支持missing, 然后需要把某些值替换成missing, 这种情况下in-place操作是不支持的。要么用=, 要么就提前执行allowmissing!:

julia

julia> df2 = ifelse.(df .== "None", missing, df) # 注意这里的df是不支持missing的, 所以用.=会报错, 只能用=
4×4 DataFrame
 Row │ a        b      c        d
     │ String?  Int64  String?  String?
─────┼──────────────────────────────────
   1 │ a            1  missing  x
   2 │ missing      2  j        y
   3 │ b            3  k        missing
   4 │ missing      4  h        z

julia> allowmissing!(df) # 或者提前把df转成支持missing的
4×4 DataFrame
 Row │ a        b       c        d
     │ String?  Int64?  String?  String?
─────┼───────────────────────────────────
   1 │ a             1  None     x
   2 │ None          2  j        y
   3 │ b             3  k        None
   4 │ None          4  h        z

julia> df .= ifelse.(df .== "None", missing, df)
4×4 DataFrame
 Row │ a        b       c        d
     │ String?  Int64?  String?  String?
─────┼───────────────────────────────────
   1 │ a             1  missing  x
   2 │ missing       2  j        y
   3 │ b             3  k        missing
   4 │ missing       4  h        z

julia

输入输出

  • CSV.jl读写各种分隔文本格式: CSV.File, CSV.read, CSV.write

julia

using CSV

df = DataFrame(CSV.File("input.csv")) # 读
CSV.write("output.csv", df) # 写

julia
  • Julia标准库中的DelimitedFiles也可以用来读写DataFrame:

julia

using DelimitedFiles, DataFrames

data, header = readdlm("in.csv", ',', header=true);
df_raw = DataFrame(data, vec(header))
df = identity.(df_raw)
#写:
writedlm("test.csv", Iterators.flatten(([names(df)], eachrow(df))), ',')

julia

Note

  • header 是一个一维矩阵Matrix, 需要转成Vector;

  • df_raw中每列的类型都是Any, 用identity缩小每列的类型, 这是因为data是一个Matrix, 内部元素应该是同一种类型(在这个例子中是Any), 所以转成的DF类型是Any;

  • 以上这些操作, 在CSV.jl中都是自动执行的, 所以还是用CSV.jl方便啊;

疑问 所以DataFrame的广播操作是按列的咯?
  • JSONTables.jl读写JSON

  • XLSX.jl读写XLSX

  • 其他格式平时不常用, 就不列举了, 用时自搜

Join操作

功能与SQL Join类似

  • innerjoin

  • leftjoin

  • rightjoin

  • outerjoin

  • semijoin: 类似innerjoin, 但是输出是以第一个df为参照

  • antijoin: 输出第一个df有,第二个df中没有的

  • crossjoin: 输出是所有df的列的笛卡尔积

julia

innerjoin(df1, df2, on = :ID) # 大部分join都用同一种语法
innerjoin(df1, df2, on = :ID_df1 => :ID_df2) # 如果要合并的列名字不一样, 用`=>`指示对应关系
crossjoin(df1, df2, makeunique = true) # crossjoin不使用`on`关键词

julia
Note

  • 如果参考列有重复值, inner|outer|left|right会把所有排列组合都输出;

  • 可以用validate=(true,true)来指示对哪些参考列对进行检查, 如果这些列对有重复值, 会报错;

  • source关键词可以用来指示参考列在两个输入df中是不是共有的:

julia

julia> outerjoin(df1, df2, on=:ID, validate=(true, true), source=:source)
3×4 DataFrame
 Row │ ID     Name     Job      source
     │ Int64  String?  String?  String
─────┼─────────────────────────────────────
   1 │    20  John     Lawyer   both
   2 │    40  Jane     missing  left_only
   3 │    60  missing  Doctor   right_only

julia

Split-Apply-Combine数据操作策略

`Split-Apply-Combine` 数据分析中的经典策略:

  1. 把数据集拆分成不同的分组;

  2. 对某些分组应用特定的方法;

  3. 合并结果成新的数据集;

DataFrame中实现的方式是利用groupby创建GroupedDataFrame数据类型, 然后结合combine, select, transform等操作对齐进行数据处理。

  • groupby: 对DataFrame进行分组

  • combine: 不限制返回行数, 行的顺序取决于group的顺序, 很适合分组计算统计信息

  • select: 返回跟原始df一样的行数和顺序的新列

  • transform: 返回原始df以及追加的新的列

支持的处理函数可以是:

  1. 标准的列指示信息: Integers, Symbols, Vector{Integers|Symbols},Strings, All, Cols, :, Between, Not, Regex

  2. cols => function键值对: 这种方法调用时, 会自动生成结果列的名字:默认是拼接输入列的名字和函数的名字, function需要返回一个值或者一个向量

  3. cols => function => target_cols方式: 显式地声明输出列的名字, 可以是单个值, 向量, 或者是AsTable, 也可以是一个cols中的名字为参数, 返回目标列名字的函数

  4. cols => target_cols方式: 重命名

  5. nrownrow => target_cols: 快速统计行数, 不显式声明名字的话, 输出名字默认是:nrow

  6. 25中键值对组成的向量或矩阵

  7. SubDataFrame类型支持的函数: 不太推荐, 因为表现力不行

Note

  • 当出现x => y这种用法时, 会先检查是不是nrow => target_cols, 如果不是, 则一律按照cols => function的逻辑去处理

  • 如果cols或者target_cols中是All, Cols, Between 或者 Not这几个特定方法时, 可以用.=>进行广播, 等同于广播names(df, cols)或者names(df, cols) 这解答了我之前的疑惑: 就是按列广播, 实际上是按列名广播

  • cols => function [=> target_cols]这种用法中, 如果colsAsTable对象, 则会把一个由cols当名字的具名元组传递给function

function的返回值

  1. 如果colstarget_cols都没有指定, 只传递了function, 则应该返回DataFrame, Matrix, NamedTuple, DataFrameRow中的一种, 返回其他类型都只会存成一列

  2. 如果target_colsSymbolString, function应该只返回一列, 这时返回DataFrame, Matrix, NamedTuple, DataFrameRow的函数会报错

  3. 如果target_colsVector{Symbol}Vector{String}AsTable, function应该返回多列, 如果function返回值是AbstractVector, 则其每个元素都得支持keys方法, 而且keys方法必须得返回Symbol, String, Integer, 如果返回Integer, 则输出列名默认是x开头(x1, x2 ...)

  4. 如果function的返回值是其他类型, 会被认为是Table.jl支持的表格类型, 然后尝试利用Tables.columntable方法获取其名字

  5. function的返回值中, 如果是Ref或者零维向量(就是空向量?)会被当作是单独的一行

当julia多核启动时, 对DataFrame调用转换函数, 会自动对每个转换事件进行并行(除非一些专门优化过的计算, 比如sum, 会把所有分组的计算单线程串行), 因此调用的函数应该是干净的(即不能修改全局变量), 或者用进程锁(太高端了暂时用不上)

可以用ByRow结构表明函数是按行执行, 而不是按列执行。

一些关键词

  • keepkeys: 分组的列是否要在结果DF中保留

  • ungroup: 输出DataFrame还是GroupedDataFrame

  • copycols: 原始DF中没被操作的列, 是否要copy

  • renamecols: cols => functions形式中,自动生成的结果列名字是否要加上原始列的名字

例子:

julia

using DataFras, CSV, Statistics

fpath = joinpath(dirname(pathof(DataFrames)), "..", "docs", "src", "assets", "iris.csv");
iris = CSV.read(fpath, DataFrame)
gdf = groupby(iris, :Species)

combine(gdf, :PetalLength => mean)
combine(gdf, nrow)
combine(gdf, nrow, :PetalLength => mean => :mean)
combine(gdf, [:PetalLength, :SepalLength] => ((p, s) -> (a=mean(p)/mean(s), b=sum(p))),
        AsTable)
combine(gdf, AsTable([:PetalLength, :SepalLength]) =>
        x -> std(x.PetalLength)/std(x.SepalLength))
combine(x -> std(x.PetalLength) / std(x.SepalLength), gdf)
combine(gdf, 1:2 => cor, nrow)
combine(gdf, :PetalLength => (x -> [extrema(x)]) => [:min, :max])

select(gdf, 1:2 => cor)
transform(gdf, :Species => x -> chop.(x, head=5, tail=0))

# do block is supported, but sould be avoided because it is slow:
combine(gdf) do df
   (m = mean(df.PetalLength), s² = var(df.PetalLength))
end

for subdf in groupby(iris, :Species)
    println(size(subdf, 1))
end

for (key, subdf) in pairs(groupby(iris, :Species))
    println("Number of data points for $(key.Species): $(nrow(subdf))")
end

julia

  • groupby结果的key是DataFrames.GroupKey类型, 可以当作是一种NamedTuple

  • groupby可以当作对DataFrame添加了查找索引, 可以通过Tuple或者NamedTuple来快速跳到指定索引:

julia

df = DataFrame(g=repeat(1:1000, inner=5), x=1:5000);
gdf = groupby(df, :g)
gdf[(g=500,)] # 用一个元素的NamedTuple, 获取分组是500的行
gdf[[(500,), (501,)]] # 用Vector{NamedTuple}, 获取两个分组

julia
  • valuecols获取所有没分组的列:

julia

combine(gdf, valuecols(gdf) .=> mean)

julia
  • GroupedDataFrame不是copy, 而是view, 所以其父DF的对应列不能改变, 也不能改变行数, 否则调用子gdf时会报错

  • 如果想父DF的改动不影响子GDF, 则要用父df的view创建gdf:gdf = groupby(view(df, :, :), :id)

  • SubDataFrames类型可以让我们快速获得df的子集, 实现类似SQL的where功能:

julia

df = DataFrame(a=1:5)
sdf = @view df[2:3, :] # SubDataFrame type
transform(sdf, :a => ByRow(string)) # 创建新的DataFrame
transform!(sdf, :a => ByRow(string)) # 本地更改sdf, 类型还是SubDataFrame
df # 更改SubDataFrame, 会对父df也一样更改, 这里df中没操作的行, 对应列填充missing
select!(sdf, :a => -, renamecols=false) # 再操作, 这里原地操作
df

julia
也可以对sdf进行分组。

ReshapingPivoting: 长宽表转换

  • stack: 宽表变长表, 会自动进行类型提升

julia

stack(iris, 1:4) # 把1-4列当成variable
stack(iris, [:SepalLength, :SepalWidth, :PetalLength, :PetalWidth])
stack(iris, Not(:Species))
# stack的第三个参数指定需要重复的列(指示列)
stack(iris, [:SepalLength, :SepalWidth], :Species)

julia
  • unstack: 长表转宽表

unstack(df::AbstractDataFrame, rowkeys, colkey, value; renamecols::Function=identity,
        allowmissing::Bool=false, allowduplicates::Bool=false, fill=missing)
unstack(df::AbstractDataFrame, colkey, value; renamecols::Function=identity,
        allowmissing::Bool=false, allowduplicates::Bool=false, fill=missing)
unstack(df::AbstractDataFrame; renamecols::Function=identity,
        allowmissing::Bool=false, allowduplicates::Bool=false, fill=missing)
julia

iris.id = 1:size(iris, 1)
longdf = stack(iris, Not([:Species, :id]))
unstack(longdf, :id, :variable, :value)
# 如果剩下的col是unique的, 可以不提供id variable:
unstack(longdf, :variable, :value)
# 甚至可以不提供variable和value:
unstack(longdf)
# 添加view=true, 不copy新数据, 而是创建原数据的view, 会节省内存
stack(iris, view=true)

julia
  • view=true时, 会创建几个向量: EachRepeatedVector对应:variable; StackedVector对应:value; Repeatedvector 对应ID cols

  • permnutedims: 反转df permutedims(df, 1)

排序

直接调用sort/sort!, 可配合ref,by关键词和order方法使用

julia

sort!(iris) # 每列都逐级参与排序
sort!(iris, rev = true)
sort!(iris, [:Species, :SepalWidth]) # 指定排序的列
sort!(iris, [order(:Species, by=length), order(:SepalLength, rev=true)]) # 指定排序方式
sort!(iris, [:Species, :PetalLength], rev=[true, false]) # 另一种指定排序方式的语法

julia

分组数据

分组数据的存储 在数据分析中, 我们经常会需要对某列数据进行分组, 分组数据通常是一些字符串, 分组数目通常不会太多 这种时候, 我们可以对分组数据进行重新编码(把string替换成一堆level), 这样的好处是减少内存更容易用groupby操作, 目前有两种类型帮助实现:

  • 来自PooledArrays.jl包中的PooledVector, 只是用来减少存储

  • 来自CategoricalArrays.jl包中的CategoricalVector, 还提供函数取回分组顺序, 在分析和画图中很有用 这个更活跃一点, 应用范围也更广

Todo 拓展学习CategoricalArrays.jl

缺失值: Missing

  • Missing类型的唯一实例是missing

  • skipmissing(x)方法可以过滤掉x中missing的值, 返回的是一个迭代器

  • coalesce可以用来替换missing为其他值: coalesce.(x, 0), 该方法是针对指定值的, 所以对向量要广播

  • dropmissing/dropmissing!: 去掉df中有missing的行, dropmissing(df, :x)指定行, 设置disallowmissing=true参数让输出的df不支持missing

  • allowmissing[!]disallowmissing[!]: 把指定df(的指定列)改成支持/不支持missing

  • Missings.jl包提供了一堆专门处理缺失值的函数, 其中一个passmissing(func)可以跳过用missing执行func, 还有Missing.replace, nonmissingtype, missings(N)等, 这里不展开了

扩展包: DataFramesMeta.jl

官方文档 HERE

拓展DataFrames.jl DataFrames.jl中的select, transformcombine等方法很强, 但是逻辑上有时候略显繁琐, 受R中dplyr和C#中LINQ的启发, DataFramesMeta.jl提供了这些方法的镜像宏, 可以用更简洁的语法来操作。除此之外, DataFramesMeta.jl还提供了其他宏操作, 如@orderby, @subset[!], @r[transform,select,orderby,subset], @by, @with, @eachrow, @byrow, @passmissing, @astable, @chain等。

最新版的DataFrames.jl中提供了Between, All, Cols, Not等列操作, 这些在DataFramesMeta.jl中暂不支持。

@select[!]

  • @select返回新的DF, 每列都是重新分配内存的

  • @select!直接操作原DF

  • 相比select, 采用了更简洁的语法: :y = f(:x)

julia

df = DataFrame(x = [1, 1, 2, 2], y = [1, 2, 101, 102]);
gdf = groupby(df, :x);
@select(df, :x, :y)
@select(df, :x2 = 2 * :x, :y)
@select(gdf, :x2 = 2 .* :y .* first(:y))
@select!(df, :x, :y)
@select!(df, :x = 2 * :x, :y)
@select!(gdf, :y = 2 .* :y .* first(:y))

julia

@transform[!]

逻辑与@select类似:

julia

@transform(df, :x2 = 2 * :x, :y)
@transform(gdf, :x2 = 2 .* :y .* first(:y))

julia

@subset[!]

julia

using Statistics
outside_var = 1;
@subset(df, :x .> 1)
@subset(df, :x .> outside_var)
@subset(df, :x .> outside_var, :y .< 102) # 两个条件是 and 的关系
@subset(gdf, :x .> mean(:x))

julia

@combine

julia

@combine(gdf, :x2 = sum(:y))
@combine(gd, :x2 = :y .- sum(:y))
@combine(gd, $AsTable = (n1 = sum(:y), n2 = first(:y)))

julia

注意最后一个例子中用$转义表明输出是一个Table格式, $转义的具体用法将在下文说明。

Note @combine的第一个参数需要是DF或GDF, 而combine可以把函数作为第一个参数, 而把GDF当作第二个参数:

julia

# 不支持的:
@combine((a=sum(:x), b=sum(:y)), gdf)
# 支持:
@combine(gdf, $AsTable = (a = sum(:x), b = sum(:y)))

julia

@orderby

@orderby对DF的多列进行排序, 只支持DataFrame, 不支持GroupedDataFrame

julia

@orderby(df, -1 .* :x)
@orderby(df, :x, :y .- mean(:y))

julia

@with

@with df 后边跟着的代码块中出现的所有Symbol类型都会被解析成是df对应列的数组, 这样需要对一个df的很多列做一系列下游计算的时候, 就可以不用重复写df.colname了, 很方便:

julia

df = DataFrame(x = 1:3, y = [2, 1, 2])
x = [2, 1, 0]

@with(df, :y .+ 1)
@with(df, :x + x)

x = @with df begin
    res = 0.0
    for i in 1:length(:x)
        res += :x[i] * :y[i]
    end
    res
end

# 用^()包裹的Symbols不会被展开成数组
@with(df, df[:x .> 1, ^(:y)])
# 上边这个结果等价于:
df[[1,2,3] .> 1, :y]
# :x 被展开了, :y 没有

julia
Note @with会生成一个函数, 所以@with内部定义的变量是局部变量. 在@with代码块内部给其外部变量赋值时, 需不需要添加global关键字取决于其外部代码块的属性:

  • 如果外部代码块是全局的, 则@with内部需要添加global关键字才能使用该变量;

  • 如果外部代码块是局部的(函数内或者let语句块内), 则不需要添加global;

Warn 因为@with会生成函数, 所以在使用return的时候要小心:
julia

function data_transform(df; returnearly = true)
    if returnearly
        @with df begin
            z = :x + :y
            return z
        end
    else
        return [1, 2, 3]
    end

    return [4, 5, 6]
end

julia
以上函数会返回[4, 5, 6], 因为@with内部的return是属于@with生成的匿名函数的, 不是data_transform的。接下来将要介绍的@eachrow是基于@with实现的, 所以也同样需要注意这个问题。

@eachrow[!]

逐行操作DF并返回操作后的DF的每行, 支持控制流和begin end代码块。

julia

df = DataFrame(A = 1:3, B = [2, 1, 2])
df2 = @eachrow df begin
    :A = :B + 1
end

julia

类似@with, 由于@eachrow生成一个函数代码块, 所以要引用外部变量时, 需要用let代码块包裹, 或者用global关键字(推荐用let, 更易懂):

julia

df = DataFrame(A = 1:3, B = [2, 1, 2], C = [-4,2,1])

# 用let包裹, 让x成为局部变量
let x = 0.0
    @eachrow df begin
        if :A < :B
            x += :A * :C
        end
    end
    x
end # x = -4.0

# y是全局变量, 在@eachrow内要用global关键字
y = 0.0
@eachrow df begin
    if :A < :B
        global y += :A * :C
    end
end;
y # y = -4.0

julia
@eachrow的特殊用法:@newcol@echorow中, 可以用@newcol宏(语法:@newcol :x::Vector{T})来分配新的类型为T的列:

julia

df = DataFrame(A = 1:3, B = [2, 1, 2])
df2 = @eachrow df begin
    @newcol :colX::Vector{Float64}
    @newcol :colY::Vector{Union{Int, Missing}}
    @newcol :colZ::Vector{String}
    :colX = :B == 2 ? pi * :A : :B
    if :A > 1
        :colY = :A * :B
    else
        :colY = missing
    end
    :colZ = string(:A)
end

julia

@byrow@r...: 逐行数据转换

@byrow可以方便地对df逐行操作, DataFramesMeta.jl中一系列的行操作宏都是基于@byrow的:

  • @rtransform, @rtransform!

  • @rselect, @rselect!

  • @rorderby

  • @rsubset, @rsubset!

DataFram.jl中, 有ByRow函数, 可以当作是按行广播操作: ByRow(f)(x, y)f.(x, y)@byrow可以理解成在DataFramesMeta.jl的宏中使用的ByRow

@byrow不是真正的宏, 不能用于DataFramesMeta.jl的宏之外。

julia

#以下两行代码是等价的:
@transform(df, @byrow :y = :x == 1 ? true : false)
transform(df, :x => ByRow(x -> x == 1 ? true : false) => :y)

julia

为了避免多次操作时重复写@byrow, 可以把@byrow写在代码块的开头, 则代码块中的所有操作都是逐行的了:

julia

@subset df @byrow begin
    :a > 1 
    :b < 5
end

julia
Note @byrow也可以用于GroupedDataFrame, 但是与ByRow类似, 在用给GDF中, 分组信息是不会被考虑的:

# 以下代码是等价的:
@transform(df, @byrow :y = f(:x))
@transform(groupby(df, :g), @byrow :y = f(:x))

@passmissing广播missing

很多Julia中的函数是不支持missing的广播的(如parse(Int, missing)会报错)。 Missing.jl包中提供了passmissing函数来处理missing。相应地, DataFramesMeta.jl中提供了@passmissing宏来在其他宏操作中支持missing。

julia

#以下代码是等价的:
@transform df @byrow @passmissing :c = f(:a, :b)
transform(df, [:a, :b] => ByRow(passmissing(f)) => :c)

julia

更具体的例子:

julia

julia> no_missing(x::Int, y::Int) = x + y;

julia> df = DataFrame(a = [1, 2, missing], b = [4, 5, 6])
3×2 DataFrame
 Row │ a        b
     │ Int64?   Int64
─────┼────────────────
   1 │       1      4
   2 │       2      5
   3 │ missing      6

julia> @transform df @passmissing @byrow :c = no_missing(:a, :b)
3×3 DataFrame
 Row │ a        b      c
     │ Int64?   Int64  Int64?
─────┼─────────────────────────
   1 │       1      4        5
   2 │       2      5        7
   3 │ missing      6  missing

julia> df = DataFrame(x_str = ["1", "2", missing])
3×1 DataFrame
 Row │ x_str
     │ String?
─────┼─────────
   1 │ 1
   2 │ 2
   3 │ missing

julia> @rtransform df @passmissing :x = parse(Int, :x_str)
3×2 DataFrame
 Row │ x_str    x
     │ String?  Int64?
─────┼──────────────────
   1 │ 1              1
   2 │ 2              2
   3 │ missing  missing

julia

@astableAsTable同时创建多列

  • @astable

在需要用同一套操作逻辑生成多列变量的场景, 可以用@astable:

julia

julia> df = DataFrame(a = [1, 2, 3], b = [400, 500, 600]);

julia> @transform df @astable begin 
           ex = extrema(:b)
           :b_first = :b .- first(ex)
           :b_last = :b .- last(ex)
       end
3×4 DataFrame
 Row │ a      b      b_first  b_last 
     │ Int64  Int64  Int64    Int64  
─────┼───────────────────────────────
   1 │     1    400        0    -200
   2 │     2    500      100    -100
   3 │     3    600      200       0

julia
  • AsTable在表达式右侧的操作

也可以用AsTable(cols)同时对多列操作, 当把AsTable用在表达式右边的时候:

  • 如果使用了AsTable(cols), 就不要在代码块中再引用其他列了

  • AsTable支持配合Not, Between, r""等使用

  • AsTable内部的东西都是被强制转义的, 所以不需要在其内部使用用$

df = DataFrame(a = [11, 14], b = [17, 10], c = [12, 5]);
vars = ["a", "b"];

@rtransform df :y = sum(AsTable(vars))
@rtransform df :y = sum(AsTable([:a, :b]))

# AsTable还支持用变量的名字进行操作
function fun_with_new_name(x::NamedTuple)
    nms = string.(propertynames(x))
    new_name = Symbol(join(nms, "_"), "_sum")
    s = sum(x)
    (; new_name => s) # (; ) => 定义具名元组
end

@rtransform df $AsTable = fun_with_new_name(AsTable([:a, :b]))

@rsubset df sum(AsTable(vars)) > 25

:y = first(AsTable("a")) # AsTable内部强制转义

如何理解AsTable的工作原理? 上边介绍了DataFrames.jl中可以当作source, 进行source => fun => dest这种操作。
DataFramesMeta.jl中, :y = f(AsTable(cols))会被翻译成AsTable(cols) => f => :y, 所以, 在用:y = fun的操作时, 不能混用AsTable:col_id:
:y = sum(AsTable(cols)) + :d 会报错。
📘 `AsTable` 和 `@astable` 目前为止, 我们看到AsTable@astable出现的三种场合:

  1. 表达式左边: $AsTable = f(:a, :b)

  2. 在表达式内用@astable

  3. 表达式右边: AsTable(cols)

这三种用法的区别总结如下:

操作目的注意
左侧的$AsTable批量创建多列, 这些列的名字是根据脚本生成的(不提前知道)需要$转移
@astable批量创建多列, 这些列名字提前已知
右侧的AsTable同时处理多列需要输入列名

$转义

DataFrameMeta.jl中, 用$充当DF的列名变量的转义符, 变量存储的值可以是SymbolStringInt表示列号(有限制), 也可以直接对字面量进行转义。

julia

df = DataFrame(A = 1:3, B = [2, 1, 2])

nameA = :A
nameA_string = "A"
df2 = @transform(df, :C = :B - $nameA)
df2 = @transform(df, :C = :B - $nameA_string)
df2 = @transform(df, :C = :B - $"A")
df2 = @transform(df, :C = :B - $:A)

julia

$也可以用于创建新列:

julia

df = DataFrame(A = 1:3, B = [2, 1, 2])

newcol = "C"
@select(df, $newcol = :A + :B)

@by(df, :B, $("A complicated" * " new name") = first(:A))

nameC = "C"
df3 = @eachrow df begin
    @newcol $nameC::Vector{Int}
    $nameC = :A
end

julia

当用$转义Int时, 有限制: 不允许混合使用Int的转义和其他类型的列名表示:

julia

@transform(df, :y = $1 + $2) # 正常
@transform(df, :y = :A + $2) # 报错

# 本质上是因为DataFrame在`source => fun => dest`表达式中, 要求`source`必须是同一种类型:
transform(df, [:A, :B] => (+) => :y)  # 正常
transform(df, [:A, "B"] => (+) => :y) # 报错

julia
Note 为了保持一致, @with@eachrow也有这种限制: 整数引用$1, $2不能与Symbol或者String类型的列引用共同使用。

$()包裹的函数会绕过DataFramesMeta.jl的匿名函数, 直接传递给DataFrames.jl的函数。这个特性使得src => func => dest可以通过$()包裹, 用在DataFramesMeta.jl的宏操作中:

julia

using Statistics

df = DataFrame(a = [1, 2], b = [30, 40]);
@transform df $([:a, :b] .=> [sum mean])

# 也可以通过变量传递:
my_transformation = :a => (t -> t .+ 100) => :c;
@transform df begin
    $my_transformation
    :d = :b .+ 200
end

julia

利用$可以在宏操作中方便地选择多列:

julia

select(df, [:a, :b])
@select df $[:a, :b]

select(df, r"^a")
@select df $(r"^a")

julia
Warn

  • $()进行多参数选择的时候, 必须保证所有的参数都被$()包裹: @select df :y = f($[:a, :b])会报错。

  • DataFrame.jl中不支持多列选择的函数, 其对应的宏也不支持:

subset(df, [:a, :b]) # error
@subset df $[:a, :b] # error

支持多列选择的宏有:

  • @select

  • @transform

  • @combine

  • @by

Warn @orderby@with没有对应的DataFrames.jl中的函数, 所以尽量不要在这两个宏中用$转义, 以后很有可能会有变动。
总结

  • 所有不被$()转义的参数, 都会被宏用于构建匿名函数, 且在这些表达式中, 只支持单列选择;

  • $()包裹的参数会被直接传给DataFrames.jl中的对应函数, 所以可以允许多列选择, 包括:

    • $[:x, :y], $["x", "y"], $[1, 2]

    • 正则表达式$(r"^a")

    • 过滤方法: $(Not(:x)), $(Between(:a, :z))

  • @with, @subset, @orderby不支持多列选择;

  • 可以用$()包裹src => fun => dest进行操作, 但是不建议这么操作

^忽略把对应的Symbol解析成列名

这个规则对所有DataFramesMeta中的宏都适用。

julia

df =DataFrame(x = [1, 1, 2, 2], y = [1, 2, 101, 102]);
@select(df, :x2 = :x, :x3 = ^(:x))

julia

@chain宏管道操作

@chain宏是来自Chain.jl包的, DataFramesMeta包重载了这个宏, 让其可以支持管道DF的宏操作:

julia

using Statistics 

df = DataFrame(a = repeat(1:5, outer = 20),
               b = repeat(["a", "b", "c", "d"], inner = 25),
               x = repeat(1:20, inner = 5))

x_thread = @chain df begin
    @transform(:y = 10 * :x)
    @subset(:a .> 2)
    @by(:b, :meanX = mean(:x), :meanY = mean(:y))
    @orderby(:meanX)
    @select(:meanX, :meanY, :var = :b)
end

# Get the sum of all columns after 
# a few transformations
@chain df begin 
    @transform(:y = 10 .* :x)
    @subset(:a .> 2)
    @select(:a, :y, :x)
    reduce(+, eachcol(_))
end

# @aside 宏用以临时跳出管道, 也是Chain.jl中的
@chain df begin 
    @transform :y = 10 .* :x
    @aside y_mean = mean(_.y)
    @select :y_standardize = :y .- y_mean
end

julia